Skip to content

Absolutely. Here is the real-world version.

Testability and Designing for Testing in .NET Systems

When people talk about testing, they often jump straight to tools: xUnit, mocks, CI pipelines, coverage reports.

In real production systems, that is not the hard part.

The hard part is this:

Can the system be exercised, observed, and verified without pain?

That is what testability really means.

In a large .NET system, especially an industrial desktop system, testability is not something you “add later.” It is a property of the design itself. If the design is hostile to testing, the team will feel it every day: fragile releases, slow debugging, fear of change, and endless manual verification on real machines.


Part 1 — Big Picture

Why testability is critical in complex systems

In a small CRUD app, lack of testability is annoying.

In a complex industrial system, it becomes dangerous.

Think about a WPF desktop app controlling a wafer inspection machine. That system is not just saving records to a database. It is doing things like:

  • sending commands to motion controllers
  • waiting for hardware responses
  • coordinating cameras, PLCs, and inspection stages
  • processing streaming events
  • running long workflows with retries, timeouts, and error recovery
  • updating operator screens in real time

A bug in that environment is not just “the wrong value showed on the page.”

It might mean:

  • the machine does not stop when expected
  • the workflow gets stuck in a half-running state
  • inspection results are saved twice
  • a timeout is handled incorrectly and the operator sees misleading status
  • intermittent race conditions appear only after 8 hours of uptime

In systems like that, you need confidence before release and fast diagnosis after release. Good testability gives you both.

It helps you answer:

  • Can I test business behavior without the machine?
  • Can I simulate failure paths?
  • Can I reproduce timing issues?
  • Can I verify recovery logic?
  • Can I change internal code without breaking hidden assumptions?

That is why testability is not a “QA concern.” It is an architecture concern.

Why “just write unit tests later” fails

This is one of the most common traps.

A team builds features quickly with direct dependencies everywhere:

  • ViewModel directly talks to hardware SDK
  • service directly calls static helpers
  • workflow logic uses DateTime.Now, Task.Delay, and global state inline
  • background loops spin forever with no control boundary
  • event handlers mutate shared state from multiple threads

Then later someone says, “Now let’s add tests.”

At that point, tests become painful because the code was not built to be controlled.

You discover things like:

  • you cannot instantiate a class without starting half the application
  • a “simple unit test” needs a database, hardware connection, config files, and UI dispatcher
  • time-based logic takes real minutes to run
  • async behavior is flaky and timing-sensitive
  • the only way to verify outcomes is by checking internal fields

So the team does one of three bad things:

  • gives up on tests
  • writes very shallow tests
  • writes brittle tests that break constantly

That is why testability must be designed in early. Not because architects love abstractions, but because untestable systems become expensive to change.

Why industrial systems are harder to test

Industrial systems are harder than normal line-of-business systems because they contain much more non-determinism and many more external boundaries.

1. Machine communication

Real hardware does not behave like a pure function.

It may:

  • disconnect unexpectedly
  • respond slowly
  • return partial data
  • send events out of order
  • hang without throwing
  • behave differently depending on firmware version

Testing against real hardware is valuable, but it is also slow, expensive, fragile, and often unavailable.

2. Long-running workflows

Industrial workflows are rarely one request, one response.

They may run for minutes or hours and move through many states:

  • Initialize
  • Home axes
  • Load recipe
  • Start scan
  • Wait for image acquisition
  • Process results
  • Save results
  • Recover from machine warning
  • Resume or abort

Testing these flows manually is exhausting. Testing them automatically requires control over time, events, dependencies, and state transitions.

3. Real-time streaming pipelines

Real-time systems often process continuous streams:

  • sensor updates
  • defect events
  • camera frames
  • machine status messages
  • telemetry and alarms

These are concurrent, asynchronous, and timing-sensitive. Bugs often appear only under load or under specific ordering conditions.

That makes testability harder, but also more important.


Part 2 — How It Actually Works

What makes code testable

Code is testable when you can do three things easily:

  • control inputs
  • observe outputs
  • replace external dependencies

That is the practical definition.

If a piece of code needs real time, real hardware, a real file system, static global state, and a live UI thread just to execute, it is not very testable.

If instead you can inject collaborators, simulate events, control timing, and inspect outcomes through clear behavior, then it is testable.

A testable unit usually has these characteristics:

  • clear responsibility
  • explicit inputs
  • explicit outputs or observable state changes
  • minimal hidden side effects
  • dependencies passed in rather than created internally
  • logic separated from framework plumbing

Dependency isolation

Dependency isolation does not mean “mock everything.”

It means the code under test should not be forced to bring along the whole world.

For example, if workflow logic depends on:

  • machine control
  • clock/time
  • persistence
  • logging
  • retry policy
  • notification publishing

then the workflow should depend on abstractions for those things, not concrete implementations that open ports, start threads, or touch disk immediately.

That lets you substitute:

  • a fake machine
  • a test clock
  • an in-memory repository
  • a spy event publisher

Now the workflow can be exercised in a controlled environment.

The goal is not fake architecture purity. The goal is controllability.

Deterministic behavior vs non-deterministic systems

A huge part of testing is making behavior deterministic.

Deterministic means: given the same setup and the same inputs, you get the same result.

Real systems are often non-deterministic because of:

  • time
  • concurrency
  • retries
  • random values
  • network latency
  • background processing
  • race conditions
  • order of event arrival

Tests become flaky when they depend on those uncontrolled variables.

Classic bad examples:

  • await Task.Delay(500) in a test and hope the work finishes
  • using DateTime.Now directly in domain logic
  • relying on thread scheduling
  • asserting exact log order across concurrent tasks
  • using real timers in unit tests

A senior engineer tries to remove or isolate that non-determinism.

For example:

  • inject a clock instead of reading system time directly
  • drive workflow progress with explicit signals instead of sleep-based waiting
  • expose completion tasks
  • use bounded channels and observable outputs
  • make state transitions explicit and assertable

That is how tests become reliable.


Part 3 — Real Problems in This System

Let’s use the concrete example:

A WPF desktop app controlling a wafer inspection machine

This is a very realistic case because it combines UI, hardware, concurrency, long-running workflows, and real-time data.

Testing without real hardware

The first rule is simple:

Most tests should not require the actual machine.

Why?

Because real hardware testing is:

  • slow
  • expensive
  • limited by lab availability
  • fragile
  • hard to automate
  • hard to reproduce consistently

Real hardware should absolutely be used for system validation, but not for every test.

So the architecture should separate:

  • machine protocol / vendor SDK layer
  • application orchestration logic
  • workflow/state logic
  • UI presentation logic

Then most tests can run against a simulated machine.

Simulating machine responses

A good machine abstraction lets you simulate realistic behavior, not just return true.

For example, real machine behavior is stateful:

  • cannot start scan before homing
  • may reject command if busy
  • may emit warning then recover
  • may time out during image capture
  • may send progress events asynchronously

A useful simulator should be able to model those things.

That lets you test scenarios like:

  • start command rejected because stage not ready
  • timeout during autofocus
  • machine disconnect during inspection
  • delayed event ordering
  • duplicate status message received

This is much more valuable than a trivial mock returning fixed values.

Testing long-running workflows

A long-running workflow is often where production bugs hide.

Example inspection workflow:

  1. Connect to machine
  2. Load recipe
  3. Home axes
  4. Start acquisition
  5. Wait for frames
  6. Process defects
  7. Save results
  8. Mark completed
  9. Recover or abort on failure

The testing challenge is not just “did it work?”

It is:

  • did it move through the right states?
  • did it react correctly to errors?
  • did it retry only where appropriate?
  • did it stop cleanly on cancellation?
  • did it avoid partial saves?
  • did it publish the correct operator-facing status?

The best way to test these is not through UI clicks alone. It is by testing the workflow engine or orchestrator directly as an application service or state machine.

Testing async and concurrent behavior

This is where many teams suffer.

Common real bugs:

  • background reader still processes events after stop
  • cancellation token is ignored in one branch
  • two concurrent events race and corrupt state
  • UI sees stale status because event ordering is inconsistent
  • result processor handles same message twice

These problems are often invisible in happy-path tests.

To test them, you need:

  • controllable async boundaries
  • deterministic signaling
  • ability to inject fake event sources
  • ability to assert state transitions after concurrent operations
  • stress or repeated tests for race-prone code

Not every concurrency bug can be fully proven away with tests, but good design reduces the surface area.

UI vs business logic testing challenges

In WPF, teams often make the UI layer too smart.

Then testing becomes painful because behavior is embedded in:

  • code-behind
  • Dispatcher-specific logic
  • visual tree assumptions
  • UI control events
  • thread-affinity logic

That makes tests slow and fragile.

A better pattern is:

  • put workflow/business decisions in services or domain/application layer
  • put presentation behavior in ViewModels
  • keep code-behind thin
  • treat WPF-specific behavior as integration/UI concern, not core logic

Then you can test most behavior without launching the real UI.


Part 4 — How We Use It in .NET (Practical)

Now let’s make this concrete.

1. Abstraction for machine: interfaces and adapters

You usually do not want the application talking directly to the vendor SDK everywhere.

Instead, wrap the SDK.

csharp
public enum MachineState
{
    Disconnected,
    Idle,
    Homing,
    Ready,
    Running,
    Error
}

public sealed record MachineStatus(MachineState State, string? Message = null);

public interface IInspectionMachine
{
    Task ConnectAsync(CancellationToken cancellationToken);
    Task HomeAsync(CancellationToken cancellationToken);
    Task StartInspectionAsync(string recipe, CancellationToken cancellationToken);
    Task StopAsync(CancellationToken cancellationToken);

    MachineStatus CurrentStatus { get; }

    event EventHandler<MachineStatus>? StatusChanged;
}

Then the real implementation adapts the vendor SDK:

csharp
public sealed class VendorInspectionMachine : IInspectionMachine
{
    private readonly VendorSdkClient _client;

    public VendorInspectionMachine(VendorSdkClient client)
    {
        _client = client;
        _client.StatusChanged += OnVendorStatusChanged;
    }

    public MachineStatus CurrentStatus { get; private set; }
        = new(MachineState.Disconnected);

    public event EventHandler<MachineStatus>? StatusChanged;

    public async Task ConnectAsync(CancellationToken cancellationToken)
    {
        await _client.OpenAsync(cancellationToken);
        UpdateStatus(new MachineStatus(MachineState.Idle));
    }

    public Task HomeAsync(CancellationToken cancellationToken)
        => _client.HomeAxesAsync(cancellationToken);

    public Task StartInspectionAsync(string recipe, CancellationToken cancellationToken)
        => _client.RunRecipeAsync(recipe, cancellationToken);

    public Task StopAsync(CancellationToken cancellationToken)
        => _client.StopAsync(cancellationToken);

    private void OnVendorStatusChanged(object? sender, VendorStatus e)
    {
        var mapped = e.Code switch
        {
            0 => new MachineStatus(MachineState.Idle),
            1 => new MachineStatus(MachineState.Homing),
            2 => new MachineStatus(MachineState.Ready),
            3 => new MachineStatus(MachineState.Running),
            _ => new MachineStatus(MachineState.Error, e.Description)
        };

        UpdateStatus(mapped);
    }

    private void UpdateStatus(MachineStatus status)
    {
        CurrentStatus = status;
        StatusChanged?.Invoke(this, status);
    }
}

This gives you a seam for testing.

2. A fake machine for tests

Instead of mocking every call mechanically, sometimes a fake is better.

csharp
public sealed class FakeInspectionMachine : IInspectionMachine
{
    public MachineStatus CurrentStatus { get; private set; }
        = new(MachineState.Disconnected);

    public event EventHandler<MachineStatus>? StatusChanged;

    public bool FailOnStart { get; set; }
    public TimeSpan StartDelay { get; set; } = TimeSpan.Zero;

    public Task ConnectAsync(CancellationToken cancellationToken)
    {
        SetStatus(new MachineStatus(MachineState.Idle));
        return Task.CompletedTask;
    }

    public Task HomeAsync(CancellationToken cancellationToken)
    {
        SetStatus(new MachineStatus(MachineState.Homing));
        SetStatus(new MachineStatus(MachineState.Ready));
        return Task.CompletedTask;
    }

    public async Task StartInspectionAsync(string recipe, CancellationToken cancellationToken)
    {
        if (StartDelay > TimeSpan.Zero)
        {
            await Task.Delay(StartDelay, cancellationToken);
        }

        if (FailOnStart)
        {
            SetStatus(new MachineStatus(MachineState.Error, "Simulated start failure"));
            throw new InvalidOperationException("Simulated machine failure");
        }

        SetStatus(new MachineStatus(MachineState.Running));
    }

    public Task StopAsync(CancellationToken cancellationToken)
    {
        SetStatus(new MachineStatus(MachineState.Idle));
        return Task.CompletedTask;
    }

    private void SetStatus(MachineStatus status)
    {
        CurrentStatus = status;
        StatusChanged?.Invoke(this, status);
    }
}

This is often better than a mock because it models behavior, not just method expectations.

3. Testing async code

Suppose we have a workflow service:

csharp
public enum InspectionWorkflowState
{
    NotStarted,
    Connecting,
    Homing,
    Running,
    Completed,
    Failed,
    Cancelled
}

public sealed class InspectionWorkflow
{
    private readonly IInspectionMachine _machine;
    private readonly IResultRepository _repository;
    private readonly ILogger<InspectionWorkflow> _logger;

    public InspectionWorkflowState State { get; private set; } = InspectionWorkflowState.NotStarted;

    public InspectionWorkflow(
        IInspectionMachine machine,
        IResultRepository repository,
        ILogger<InspectionWorkflow> logger)
    {
        _machine = machine;
        _repository = repository;
        _logger = logger;
    }

    public async Task RunAsync(string recipe, CancellationToken cancellationToken)
    {
        try
        {
            State = InspectionWorkflowState.Connecting;
            await _machine.ConnectAsync(cancellationToken);

            State = InspectionWorkflowState.Homing;
            await _machine.HomeAsync(cancellationToken);

            State = InspectionWorkflowState.Running;
            await _machine.StartInspectionAsync(recipe, cancellationToken);

            await _repository.SaveRunAsync(recipe, "Completed", cancellationToken);

            State = InspectionWorkflowState.Completed;
        }
        catch (OperationCanceledException)
        {
            State = InspectionWorkflowState.Cancelled;
            _logger.LogWarning("Inspection workflow cancelled.");
            throw;
        }
        catch (Exception ex)
        {
            State = InspectionWorkflowState.Failed;
            _logger.LogError(ex, "Inspection workflow failed.");
            throw;
        }
    }
}

A test can verify behavior:

csharp
public class InspectionWorkflowTests
{
    [Fact]
    public async Task RunAsync_GivenHealthyMachine_CompletesSuccessfully()
    {
        var machine = new FakeInspectionMachine();
        var repository = new InMemoryResultRepository();
        var logger = NullLogger<InspectionWorkflow>.Instance;

        var workflow = new InspectionWorkflow(machine, repository, logger);

        await workflow.RunAsync("recipe-A", CancellationToken.None);

        Assert.Equal(InspectionWorkflowState.Completed, workflow.State);
        Assert.Single(repository.SavedRuns);
        Assert.Equal("recipe-A", repository.SavedRuns[0].Recipe);
    }

    [Fact]
    public async Task RunAsync_GivenMachineStartFailure_MarksWorkflowFailed()
    {
        var machine = new FakeInspectionMachine { FailOnStart = true };
        var repository = new InMemoryResultRepository();
        var logger = NullLogger<InspectionWorkflow>.Instance;

        var workflow = new InspectionWorkflow(machine, repository, logger);

        await Assert.ThrowsAsync<InvalidOperationException>(
            () => workflow.RunAsync("recipe-A", CancellationToken.None));

        Assert.Equal(InspectionWorkflowState.Failed, workflow.State);
        Assert.Empty(repository.SavedRuns);
    }
}

Notice the test is checking behavior:

  • final workflow state
  • whether persistence happened
  • whether failure prevented save

It is not checking internal call order line by line unless that order is itself the business behavior.

4. Mocking dependencies

Mocks are useful when you need to verify interaction with external collaborators.

Example with Moq:

csharp
[Fact]
public async Task RunAsync_ShouldSaveResultAfterSuccessfulRun()
{
    var machine = new Mock<IInspectionMachine>();
    machine.Setup(x => x.ConnectAsync(It.IsAny<CancellationToken>()))
        .Returns(Task.CompletedTask);
    machine.Setup(x => x.HomeAsync(It.IsAny<CancellationToken>()))
        .Returns(Task.CompletedTask);
    machine.Setup(x => x.StartInspectionAsync("recipe-A", It.IsAny<CancellationToken>()))
        .Returns(Task.CompletedTask);

    var repository = new Mock<IResultRepository>();
    repository.Setup(x => x.SaveRunAsync("recipe-A", "Completed", It.IsAny<CancellationToken>()))
        .Returns(Task.CompletedTask);

    var workflow = new InspectionWorkflow(
        machine.Object,
        repository.Object,
        NullLogger<InspectionWorkflow>.Instance);

    await workflow.RunAsync("recipe-A", CancellationToken.None);

    repository.Verify(
        x => x.SaveRunAsync("recipe-A", "Completed", It.IsAny<CancellationToken>()),
        Times.Once);
}

That is fine.

But a warning: do not turn every test into a mock-script theater production. If the test is mostly verifying that one mock called another mock in a precise order, it often says more about the implementation than the behavior.

5. Testing state machines and workflows

State-heavy systems are perfect candidates for explicit tests.

For example:

csharp
public sealed class InspectionStateMachine
{
    public InspectionWorkflowState State { get; private set; } = InspectionWorkflowState.NotStarted;

    public void HandleStartRequested()
    {
        if (State != InspectionWorkflowState.NotStarted)
            throw new InvalidOperationException("Start only allowed from NotStarted.");

        State = InspectionWorkflowState.Connecting;
    }

    public void HandleConnected()
    {
        if (State != InspectionWorkflowState.Connecting)
            throw new InvalidOperationException("Connected only allowed from Connecting.");

        State = InspectionWorkflowState.Homing;
    }

    public void HandleHomed()
    {
        if (State != InspectionWorkflowState.Homing)
            throw new InvalidOperationException("Homed only allowed from Homing.");

        State = InspectionWorkflowState.Running;
    }

    public void HandleCompleted()
    {
        if (State != InspectionWorkflowState.Running)
            throw new InvalidOperationException("Completed only allowed from Running.");

        State = InspectionWorkflowState.Completed;
    }

    public void HandleFailure()
    {
        State = InspectionWorkflowState.Failed;
    }
}

Tests:

csharp
public class InspectionStateMachineTests
{
    [Fact]
    public void HandleStartRequested_FromNotStarted_MovesToConnecting()
    {
        var sm = new InspectionStateMachine();

        sm.HandleStartRequested();

        Assert.Equal(InspectionWorkflowState.Connecting, sm.State);
    }

    [Fact]
    public void HandleCompleted_FromWrongState_Throws()
    {
        var sm = new InspectionStateMachine();

        Assert.Throws<InvalidOperationException>(() => sm.HandleCompleted());
    }
}

This style is very valuable in industrial systems because explicit state rules reduce ambiguity and make both production behavior and tests clearer.

6. Separating ViewModel from UI

A ViewModel should be testable without opening a real WPF window.

Example:

csharp
public interface IInspectionCoordinator
{
    Task StartAsync(CancellationToken cancellationToken);
    Task StopAsync(CancellationToken cancellationToken);
}

public sealed class MainViewModel : INotifyPropertyChanged
{
    private readonly IInspectionCoordinator _coordinator;
    private bool _isRunning;
    private string _status = "Idle";

    public MainViewModel(IInspectionCoordinator coordinator)
    {
        _coordinator = coordinator;
        StartCommand = new AsyncRelayCommand(StartAsync, () => !IsRunning);
        StopCommand = new AsyncRelayCommand(StopAsync, () => IsRunning);
    }

    public event PropertyChangedEventHandler? PropertyChanged;

    public bool IsRunning
    {
        get => _isRunning;
        private set
        {
            if (_isRunning == value) return;
            _isRunning = value;
            OnPropertyChanged(nameof(IsRunning));
            OnPropertyChanged(nameof(CanStart));
            OnPropertyChanged(nameof(CanStop));
            StartCommand.RaiseCanExecuteChanged();
            StopCommand.RaiseCanExecuteChanged();
        }
    }

    public string Status
    {
        get => _status;
        private set
        {
            if (_status == value) return;
            _status = value;
            OnPropertyChanged(nameof(Status));
        }
    }

    public bool CanStart => !IsRunning;
    public bool CanStop => IsRunning;

    public AsyncRelayCommand StartCommand { get; }
    public AsyncRelayCommand StopCommand { get; }

    private async Task StartAsync()
    {
        Status = "Starting...";
        await _coordinator.StartAsync(CancellationToken.None);
        IsRunning = true;
        Status = "Running";
    }

    private async Task StopAsync()
    {
        Status = "Stopping...";
        await _coordinator.StopAsync(CancellationToken.None);
        IsRunning = false;
        Status = "Idle";
    }

    private void OnPropertyChanged(string propertyName)
        => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}

Test:

csharp
[Fact]
public async Task StartCommand_UpdatesRunningStateAndStatus()
{
    var coordinator = new Mock<IInspectionCoordinator>();
    coordinator.Setup(x => x.StartAsync(It.IsAny<CancellationToken>()))
        .Returns(Task.CompletedTask);

    var vm = new MainViewModel(coordinator.Object);

    await vm.StartCommand.ExecuteAsync(null);

    Assert.True(vm.IsRunning);
    Assert.Equal("Running", vm.Status);
    Assert.False(vm.CanStart);
    Assert.True(vm.CanStop);
}

This is the kind of WPF testing that scales. Test ViewModel behavior. Do not try to make every unit test validate XAML rendering.


Part 5 — Common Mistakes (Very Realistic)

1. Tightly coupled code

Example smell:

  • ViewModel creates hardware SDK directly
  • service uses new SqlConnection(...) inside method
  • business logic reads files directly
  • static global singleton everywhere

Why it is bad:

You cannot isolate the unit under test. Every test becomes integration-heavy.

Production consequence:

  • harder to reproduce bugs
  • fear of refactoring
  • releases slow down because manual testing explodes
  • teams rely too much on hero debugging

2. Testing implementation instead of behavior

Bad tests often look like this:

  • verify method A called method B called method C
  • assert private/internal sequencing details
  • fail when code is refactored but behavior is unchanged

Good tests ask:

  • what observable behavior should happen?
  • what state transition should occur?
  • what output should be persisted or published?
  • what error should be surfaced?

Production consequence:

The team stops trusting tests because harmless refactors break them.

3. Over-mocking everything

Mocks are useful, but too much mocking creates fake confidence.

If every dependency is mocked, the test environment may no longer resemble reality at all.

You end up with tests that pass while the real system fails because:

  • serialization format was wrong
  • DB transaction behavior differed
  • event ordering was different
  • retry policy interacted badly with external service
  • configuration wiring was broken

Production consequence:

A green test suite hides integration defects.

4. Ignoring integration testing

Some teams swing too far into unit testing and underinvest in integration testing.

In .NET systems, many failures occur at boundaries:

  • DI registration problems
  • configuration binding issues
  • EF Core mapping mistakes
  • message serialization issues
  • hardware adapter translation bugs
  • async pipeline composition issues

Those are not caught by pure unit tests.

Production consequence:

The first true integration environment becomes the test environment, which is too late.

5. Not testing failure scenarios

This is one of the biggest real-world mistakes.

Teams test:

  • connect success
  • start success
  • save success

But they do not test:

  • connect timeout
  • machine disconnect during run
  • duplicate event delivery
  • persistence failure after inspection completes
  • cancellation during stop
  • corrupted response payload
  • retry exhausted

Production consequence:

The system looks stable in demos but behaves badly under stress, faults, and recovery conditions.

And that is exactly where industrial systems live.


Part 6 — Performance & Trade-Offs

Testability vs complexity

Yes, designing for testability adds some complexity.

You introduce:

  • interfaces
  • adapters
  • dependency injection
  • seams for time and I/O
  • explicit workflow/state boundaries

That is more structure than just writing everything inline.

But in complex systems, that structure often pays for itself quickly.

The wrong way to think is: “abstractions are overhead.”

The better way is: “where do abstractions reduce operational risk and enable controlled testing?”

Not every class needs an interface. Not every method needs indirection.

But volatile external boundaries usually do.

Abstraction overhead

In .NET, interface calls and adapter layers do have some overhead, but in most industrial desktop systems this is not the bottleneck.

Your real bottlenecks are more likely to be:

  • hardware I/O
  • image processing
  • serialization
  • UI rendering
  • disk or network latency
  • lock contention
  • unnecessary allocations

Do not destroy maintainability to save nanoseconds at the wrong layer.

If a hot path truly needs optimization, measure it and optimize surgically.

Speed vs realism in tests

There is always a trade-off.

  • pure unit tests are fast, isolated, and precise
  • integration tests are slower but more realistic
  • full end-to-end tests are most realistic but often slowest and least stable

A mature test strategy uses all three.

If all tests are fast but fake, you miss reality. If all tests are realistic but slow, the team stops running them.

The goal is a balanced pyramid, or really in many enterprise systems, a balanced portfolio.


Part 7 — Senior Engineer Thinking

How experienced engineers design for testability from day 1

A senior engineer does not wait for QA pain before thinking about testability.

They ask early:

  • where are the unstable boundaries?
  • which parts of this workflow will be hard to reproduce manually?
  • where will concurrency or timing make bugs hard to catch?
  • how can we simulate hardware?
  • how will we test cancellation, timeout, and retry behavior?
  • can core logic run without UI, database, or real machine?

This shapes the design from the beginning.

You can often recognize senior design by these traits:

  • framework-independent business logic
  • explicit boundaries around hardware and infrastructure
  • state machines or orchestrators with assertable transitions
  • minimal logic in code-behind
  • async flows with cancellation and completion boundaries
  • logs and outputs designed to be observable in both tests and production

What to test vs not test

This is where judgment matters.

Test heavily:

  • business rules
  • workflow transitions
  • failure handling
  • retry/timeout/cancellation logic
  • mapping between machine events and application behavior
  • ViewModel behavior
  • integration boundaries that often break

Test selectively:

  • complicated LINQ or transformation logic
  • concurrency-sensitive coordination
  • configuration and DI wiring
  • persistence behavior with realistic test environments

Test lightly or not directly:

  • trivial property getters/setters
  • framework behavior already guaranteed by WPF/.NET
  • private helper methods as isolated targets
  • implementation details that are not behaviorally meaningful

Senior engineers focus testing effort where bugs are expensive and likely.

How to balance unit, integration, and system tests

A healthy strategy for a .NET industrial application often looks like this:

Unit tests

Fast and numerous.

Use for:

  • business logic
  • state machines
  • ViewModels
  • workflow branching logic
  • error handling decisions

Integration tests

Smaller in number, high value.

Use for:

  • repository behavior
  • DI composition
  • adapter translation
  • message pipeline wiring
  • interaction with local simulators or in-memory infrastructure

System/end-to-end tests

Fewer, targeted, realistic.

Use for:

  • machine simulator integration
  • critical operator workflows
  • startup/shutdown paths
  • alarm handling
  • recipe load and inspection execution
  • real hardware validation in controlled environments

Do not expect one kind of test to do all jobs.

How to keep tests maintainable

This matters a lot. Test suites can rot just like production code.

Good practices:

  • test behavior with intention-revealing names
  • avoid giant setup blocks
  • use builders/factories for test data
  • prefer fakes when behavior matters
  • use mocks only where interaction verification is the real point
  • remove duplication carefully
  • keep tests readable enough that failures explain the problem
  • do not let tests depend on timing guesses

Since you prefer xUnit and Given_When_Then naming, a real example would be:

csharp
[Fact]
public async Task Given_machine_start_fails_When_running_workflow_Then_state_is_failed_and_result_is_not_saved()
{
    var machine = new FakeInspectionMachine { FailOnStart = true };
    var repository = new InMemoryResultRepository();
    var workflow = new InspectionWorkflow(
        machine,
        repository,
        NullLogger<InspectionWorkflow>.Instance);

    await Assert.ThrowsAsync<InvalidOperationException>(
        () => workflow.RunAsync("recipe-A", CancellationToken.None));

    Assert.Equal(InspectionWorkflowState.Failed, workflow.State);
    Assert.Empty(repository.SavedRuns);
}

That is clear and maintainable.


Final Practical Mental Model

In real .NET systems, testability is not about chasing coverage.

It is about designing software so that important behavior can be verified without pain.

For an industrial WPF system, that usually means:

  • isolate hardware behind adapters
  • keep workflow logic separate from device SDK and UI
  • make state transitions explicit
  • control time and async boundaries
  • simulate machine behavior realistically
  • test failures, not just happy paths
  • keep ViewModels testable without WPF runtime
  • combine unit, integration, and system testing intentionally

The strongest teams do not treat testing as a separate activity after coding.

They treat testability as one of the signs of good architecture.

And in interviews, that is a very strong senior-level message:

Untestable code is often poorly designed code. Not always, but very often.

Because when code is hard to control, hard to observe, and hard to isolate, it is usually also hard to reason about, hard to debug, and hard to evolve.

If you want, next I can turn this into a Part 2 interview-style Q&A set with senior/principal-level questions and model answers on testability in .NET systems.

Docs-first project memory for AI-assisted implementation.